-
Notifications
You must be signed in to change notification settings - Fork 95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow for compression of the oplog in case of mergeable operations. #100
base: main
Are you sure you want to change the base?
Conversation
Codecov Report
|
This is super interesting, and I'm not ignoring it, just need to find time to do a proper review! |
No problem. Gives me more time to iron out any kinks I find. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really exciting change, and I love all the thought you've put into it. I think what I'd like to see above all is a benchmark to see that this compression actually manifests as a meaningful speedup to writes. I think there is an argument to be made that the compression logic may end up being slower than just doing the extra operations, and would like to see some benchmarks refuting that. You'll probably want knobs to dictate the distance between publishes, the cost of execution an operation, and the cost of attempt to compress two operations.
Separately, this logic is fairly involved, and even though you've done a good job of writing test cases, I'd like to see a quickcheck
test or two added as well, just because there are so many corner-cases to consider.
Co-authored-by: Jon Gjengset <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took another look through — I'm really liking the changes so far!
/// and [`TryCompressResult::Dependent`] that they can not be compressed, and `prev` must precede `next`. | ||
/// | ||
/// Defaults to [`TryCompressResult::Dependent`], which sub-optimally disables compression. | ||
/// Setting [`Self::MAX_COMPRESS_RANGE`](Absorb::MAX_COMPRESS_RANGE) to or leaving it at it's default of `0` is vastly more efficient for that. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think rustfmt may complain about the line wrapping of this comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have vscode configured to automatically run cargo fmt
on save and cargo doc
doesn't complain about it, so I think it's fine? The single break between 'Defaults...' and 'Setting...' is purely meant to make reading it in an editor easier and should be turned into a simple space on docs.rs.
benches/benchmark.rs
Outdated
@@ -0,0 +1,207 @@ | |||
use std::collections::{BTreeMap, HashMap}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you post the results from these once you feel like they're in a decent place?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do. Done for today though. In their current state compression with unlimited range against no compression is about 20% faster for btreemap and about 35% faster for hashmap, with limited compression falling somewhere in between. Considering that due to the higher lookup-cost btreemap should be the one benefitting more there definitely is something fishy going on.
Co-authored-by: Jon Gjengset <[email protected]>
While working on the benchmarks I just had an epiphany about an even more powerful and general improvement: Custom oplog data structures. The original idea behind compression was to combine related ops, but the purely linear nature of the oplog meant I was essentially creating an unordered vec-map with linear lookup, which was fine and worthwhile for small maps but comes to a screeching halt for larger ones. Then why not make the oplog itself a map and allow for custom append logic? While this idea can be user-implemented by using compression to have a singleton op contain it (which is an excellent use case!), it would be less convoluted to have I'm going to start another branch, tell me if you want me to open a new PR or merge it into this one. Edit: Wulf0x67E7:custom-log. Edit: While implementing it I realized that by replacing
|
I like it — give it a shot! |
What
Adds one associated const and fn each to
Absorb
, as well the new TypeTryCompressResult
returned by that fn. Together they allowWriteHandle::extend
(and by extensionWriteHandle::append
) to optimize the op-log by combining certain operations into one.Why
Reading/Watching about left-right/evmap it was mentioned that on top of the base doubling (unless carefully deduplicated) of memory the op-log can get quite out of hand if only infrequently published. That got me thinking about how, in a toy example of a simple map, you could combine set-ops with the same key to be the last one, merge certain modify-ops (like add), and completely consume the op-log when emitting a clear-op. Then I looked at the source and found it quite straightforward to implement. So I did (in about six hours).
How
When compression is enabled
WriteHandle::extend
reverse-walks the fully unpublished portion of the op-log while attempting to combine ops. It does this by callingAbsorb::try_compress
and either:TryCompressResult::Compressed
TryCompressResult::Independent
TryCompressResult::Dependent
After finishing for all ops it then stably moves all empty entries to the end of the op-log before truncating it.
I have also added three (edit: four) complementary optimizations to avoid some pathological worst cases:
Absorb::MAX_COMPRESS_RANGE
TryCompressResult::Independent
s in a row to avoid iterating the full op-log in case it is filled with predominantly independent ops.Caveats
WriteHandle::extend
, the op-log now contains options instead of values. As ops are basically always enums, enum folding andopt.unwrap_or_else(|| unreachable!())
should(?) remove any overhead.debug_assert!(...)
s, which should leave no traces in a release build.Is it useful?
Frankly, I don't know. I haven't actually used left-right in any serious projects and so can't really judge if this optimization is worthwhile. However, because compression is controlled via an associated const, it should monomorphize away and so at least not degrade performance when disabled, otherwise, it basically shifts writer-side computation from publishing to extending while potentially saving a fair chunk of memory. (Though come to think of it, it should also be quite straightforward to move the compression into
WriteHandle::publish
, though it then wouldn't be saving any memory, which likely is the main benefit.)This change is